Skip to content

bun test: Vitest compatibility - #44833

Merged
Jarred-Sumner merged 385 commits into
mainfrom
claude/vitest-compat
Oct 31, 2026
Merged

Jarred-Sumner merged 385 commits into
mainfrom
claude/vitest-compat

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Makes bun test run Vitest suites. It adds the missing Vitest and Jest APIs, makes --isolate and --parallel much cheaper, and fixes the runtime bugs that turned up along the way. Expected behavior comes from running Vitest 5.0.3 and Jest 30.5.2.

When Vitest and Jest disagree, you get the behavior of the module you imported from. "vitest" is now its own module with Vitest's semantics. bun:test and @jest/globals keep theirs.

The engine side is oven-sh/WebKit#781, which main's WebKit already has.

Module mocks

  • Top-level vi.mock, vi.unmock, vi.hoisted, jest.mock and jest.unmock are hoisted above the file's imports, including jest.mock(a).mock(b) chains. mock.module, vi.doMock and nested calls aren't hoisted. A file with no such call transpiles to the same bytes as before.
  • A getter returned by a factory stays a getter for every importer, through a binding or a namespace, in every JIT tier. Fixes [bun:test] mocking mutable exports (let) with mock.module is broken #9874.
  • If a factory doesn't return an export that some module imports, that import no longer fails at link time. Reading the missing export throws, as in Vitest, for vi.mock / vi.doMock and gives undefined for mock.module and jest.mock. A default import of a jest.mock factory's object is the object itself unless it has __esModule, as in Jest.
  • Partial mocks work behind barrel files, as in Vitest. A name the factory didn't return is undefined when it's imported through another module's export * from or export { name } from, and listing the barrel's exports doesn't throw. If a barrel gets a name from a mock and from another export *, the first wins.
  • A top-level const / let initialized with a plain literal is hoisted along with the mocks, like babel-plugin-jest-hoist does. const name = "x"; jest.mock(p, () => ({ name })) works.
  • Mocks work inside import cycles, also with importOriginal.
  • What a factory loads with vi.importActual() or import() gets the file's other mocks and the same module instances as everything else.
  • importOriginal (the first argument of every factory), vi.importActual, jest.requireActual.
  • vi.doMock(), vi.importActual() and the like, and toMatchInlineSnapshot(), find their caller also as the last call of a function: the transpiler makes them no tail calls.
  • Mocking without a factory: automock, __mocks__, { spy: true }, vi.importMock, jest.requireMock, jest.createMockFromModule, vi.mockObject.
  • You can write to a mocked module, as in Vitest. ns.x = v, Object.assign, Object.defineProperty and delete on what import * as, import() or vi.importMock() gives you change the object the factory returned, so every importer sees the change. The namespace object of a module that isn't mocked stays read-only.
  • vi.doMock, vi.doUnmock, vi.resetModules, jest.resetModules. require("node:fs") respects mocks.
  • vi.mock() and vi.doMock() of a package mock it for the project's own modules. What Vitest externalizes gets the real one: all of node_modules except ES module syntax in a .js file without "type": "module". jest.mock() and mock.module() mock it for every importer. Before, a CommonJS library got the ES-shaped { default } object.
  • vi.resetModules() doesn't reload what Vitest externalizes, so a component imported again shares its copy of react with the test. jest.resetModules() reloads it.
  • A real package stays one instance. If it's already loaded when vi.mock() or vi.doMock() mocks it, its importers keep it, the factory runs at the next import, vi.importActual() returns the loaded instance, and vi.doUnmock() brings it back. vi.doMock() does that for any module the test file has loaded, as in Vitest.
  • It's safe to unmock, re-mock or reset modules while a load is in flight (from a factory, a getter or a plugin callback). A module that's still loading is removed from the registry once its load finishes.
  • Fixes a segfault when a pending factory resolves to a non-object.
  • A test file or preload registered through "vitest" runs its imports one at a time, as Vitest does, even with no hoisted call of its own. So what it imports first can mock what the next import loads. Before, all were linked first and the mock overwrote the loaded module, so exports only the mock has were lost.
  • Where imports stay static, vi.mock() of a module that is linked but hasn't run runs it first, and patches it again if its body waits for an async factory. Before: Cannot access 'x' before initialization, or const exports assigned over the mock.
  • vi.importActual() neither calls the factory nor waits for it, unless the original's imports lead back to the mock.
  • A module that only vi.importActual() loaded isn't updated in place by vi.mock(). It stays the original. The next import calls the factory.
  • vi.importMock()'s result inherits from Object.prototype.
  • A vi.mock() in a module that the test file imports reached nobody if it mocked a package, a module a preload loaded or one vi.importActual() loaded. It is now updated in place, so a library in node_modules gets the mock too, as in Jest.
  • A vi.mock() / vi.doMock() factory may wait for something a test does. The file failed with "factory never settled".
  • Without --isolate, a helper that calls vi.mock() mocks for every file that imports it, not only the first.
  • import() in a factory gave an empty module for a few ticks after a mock's factory in its graph had returned.
  • A factory that ended with N loads pending walked the registry about 2^N times: in a large graph, a minute of CPU before the first test.
  • A vi.doMock() factory that never settles no longer hides the module's next mock from what a later test imports.
  • The async factories that one import calls ran at the same time, so what each got from import() depended on timing. They now run one at a time in import order, as in Vitest. A factory with no source file, like mock(() => …), can load a module that imports the mocked one. That was a Circular import error.
  • A module runs once when a factory imports it while another import() is still loading it. It often ran twice, which makes two copies of a class, a registry or a React context.

Mock functions

  • spyOn(obj, "x", "get" | "set"). Spies work on inherited and native accessors and through a Proxy, and restoring puts back the exact original descriptor.
  • new on a mock: class implementations, mock.instances, prototype, static members.
  • vi.mocked, vi.isMockFunction, mock.settledResults, mockThrow, mockThrowOnce.
  • vi.fn / vi.spyOn: mockReset() restores the original implementation, restoreAllMocks() keeps the recorded calls, and fn.mock is the same object after mockClear().
  • clearAllMocks() / resetAllMocks() only visit mocks that were used since the last call, not every live mock in the process.
  • getMockImplementation() returns a function after mockReturnValue(), mockResolvedValue() and the like, as in Jest and Vitest. It returned undefined.
  • spyOn() on the global of a node:vm context works for a method that the contextified object inherits, such as addEventListener of a jsdom iframe's window. The spy recorded nothing.

Fake timers

  • toFake, doNotFake, shouldAdvanceTime, now, loopLimit, setTimerTickMode, and the four *Async functions. Fixes bun:test: useFakeTimers ignores toFake (and doNotFake), so the faked set cannot be chosen #44605.
  • Whether a timer is real or fake depends on the function that created it. A setTimeout reference saved before useFakeTimers() stays real.
  • vi.waitFor, vi.waitUntil, setSystemTime with a date string, getMockedSystemTime, getRealSystemTime.
  • Real timers are restored at the end of every test file.
  • The timers Bun's builtin modules set for themselves (net, http, perf_hooks) stay real under fake timers. Those of ws don't.
  • requestAnimationFrame / cancelAnimationFrame are faked by default when they exist on globalThis, as in Jest and Vitest.
  • A fake setTimeout() without a delay is due right away, or 1 ms later if it's set while a fake timer's callback runs, as in Vitest and Jest. advanceTimersByTime(0) runs it without moving the clock. All 43 fake-clock cases run on Vitest 5.0.3 give the same log (18 before).
  • vi from "vitest" leaves AbortSignal.timeout() on the real clock and replaces the performance object, as Vitest does. Code that kept the old one, like React's scheduler, reads the real clock and never calls a spy on now.
  • A spy or an assignment on Date.now or performance.now hid the fake clock. Now it makes way and is back after useRealTimers(), as in Jest and Vitest.

Test API

  • Tests registered through "vitest" get a test context (task, expect, skip, signal, onTestFinished, onTestFailed, annotate), test.extend fixtures, test.for, option objects, teardown functions returned from beforeAll / beforeEach, after-hooks in reverse order, and no done parameter.
  • "vitest" exports inject (returns undefined) and TestRunner.getCurrentTest() (the running test's task).
  • import { assert, should } from "vitest" imports them from the project's chai, or the one pnpm links next to vitest. A file that doesn't import them doesn't load chai.
  • Opt-in types for "vitest" without the package: "types": ["bun-types/vitest"], or bun-types/vitest-globals for vitest/globals. They match Vitest 5 and don't load Bun's globals.
  • test.runIf, test.sequential, and test.fails with Vitest's rule: it inverts the final result, so a timeout or a failing hook counts as the expected failure. test.failing keeps Jest's rule. Neither takes an uncaught exception or unhandled rejection for the expected failure. Modifiers chain in any order. .each accepts tagged-template tables.
  • .resolves, .rejects, toThrow on an async function, and async custom matchers return a promise and never spin the event loop. One exception: in a bun:test test, a settled promise is checked right away, and a failed matcher throws as before. Fixes Matchers under expect().resolves and expect().rejects should return a promise but return undefined instead #23420, bun test waits at 100% cpu usage for a promise that will resolve after expect() #14950, Support expect(AsyncFunction).rejects #16144, expect(...).resolves.toSatisfy(...) passes in the promise directly #15428, expect(thenable).resolves not working for custom thenable objects in bun:test #18857, Timeout in test when expecting promise from bun shell to throw #14670, bun:test: expect(thenable).rejects hangs when the thenable starts work in its overridden then() #40949.
  • expect.soft, expect.poll.
  • expect.getState, expect.setState, expect.addEqualityTesters, toHaveBeenCalledExactlyOnceWith, toHaveBeenCalledBefore / After, the toHaveResolved* family.
  • Custom matchers: this.equals and this.utils.* work without a receiver, matcherHint matches Jest's, message() is thrown verbatim, this.utils.diff and printWithType exist, and expect(x, "label") prefixes the message. Of 44 jest-dom and testing-library messages, 32 equal Jest's (0 before), and 10 differ only in [ 1 ] vs [1].
  • vi.stubEnv, vi.stubGlobal, vi.unstubAll*, vi.dynamicImportSettled (as in Vitest, it waits for no mock's factory, and for no import from before vi.resetModules()). Stubs made by a preload apply to every file.
  • vi.setConfig: testTimeout, hookTimeout, maxConcurrency, clearMocks, mockReset, restoreMocks, unstubEnvs, unstubGlobals. The last five are also [test] keys in bunfig.toml.
  • describe.shuffle and { shuffle }, reproducible with the printed --seed.
  • [test] globals = "vitest" / --globals=vitest for suites that use Vitest's globals without importing them. As with Vitest's globals: true, the names are also properties of globalThis. @testing-library/user-event looks for globalThis.afterEach there.
  • expect.extend(namespace) accepts import * as matchers.
  • Injected globals are still available in a file that imports from the test module.
  • test.each titles fill in every placeholder. Fixes test.each: %s is not substituted for non-string values in the test title #37821.
  • Through "vitest", each and for titles match Vitest's. So a .snap file that Vitest wrote for them is found. Of 25,353 generated cases, 25,113 titles equal Vitest 5.0.3's. The rest hold a value that differs in Bun, such as a function's source.
  • --reporter-junit-suites=flat. The default is unchanged.
  • aroundEach(fn) and aroundAll(fn), from both modules, on test and as globals. fn(runTest, context) wraps a test with its hooks, teardowns and onTestFinished, once per retry and repeat. fn(runSuite) wraps a block or file. What runs inside sees the AsyncLocalStorage store of runTest(). The timeout doesn't count the time inside.
  • A thenable that isn't a promise is awaited when a test, a hook or a describe callback returns it.
  • afterEach runs right after the test, before any pending timer, immediate or I/O callback, as in Jest and Vitest. A timer that was already due could fire in between.
  • expect.toSatisfy(fn, message?), the asymmetric matcher.
  • Any object with an asymmetricMatch() method is a matcher, as in Jest and Vitest.
  • .resolves.not.toThrow() passes when the promise resolved to something that isn't a function.
  • toThrow() reported every promise rejected before the call as unhandled, even one the function went on to handle.
  • Through "vitest":
    • A hook registered while a test runs is ignored. bun:test still throws.
    • An unhandled rejection or uncaught exception goes to the file's process.on("unhandledRejection") / process.on("uncaughtException") listeners first. With a listener it doesn't fail the test.
    • toHaveProperty("a.b") finds an own key spelled a.b before it reads a path, and throws a TypeError on null and undefined.
    • toContain() on a string converts its argument to a string. On a DOM node it calls contains(). .not.not stays negated. A promise that resolved to an Error didn't throw for .resolves.toThrow().

Snapshots

  • A .snap file stays in the format its header names. Files written by Vitest or Jest are read, matched and updated in that tool's format (keys, pretty-format bodies, sorting), byte for byte.
    • Test corpus: 10 test files whose .snap and inline snapshots were written by the real tools. 512 of 514 tests pass for Vitest 5.0.3 (17 before) and 493 of 496 for Jest 30.5.2 (224 before).
    • In the other direction, Vitest accepts 512 of 514 when Bun writes everything, and 8 of the 10 .snap files are byte-identical to Vitest's.
  • Inline snapshots written by the other runners match too.
  • toMatchFileSnapshot, expect.addSnapshotSerializer (both plugin interfaces).
  • In a Jest-format or Vitest-format file, -u removes obsolete entries and keeps the entries of skipped, filtered and failed tests. A Bun-format file is written as before and only keeps the entries of tests that ran.
  • Some values print differently now (accessors, non-enumerable properties, DOM nodes, React 19 elements). When a snapshot doesn't match, Bun also compares it against what earlier versions printed. If that matches, the snapshot passes and is left alone until --update-snapshots rewrites it. Two known exceptions need updating by hand:
    • a snapshot with property matchers on an arguments object, or on an object that appears more than once in the value or contains itself
    • a snapshot of a real happy-dom element (5 MB each), because the new printer fills the element's lazy caches before the old form is printed

DOM

  • // @vitest-environment jsdom | happy-dom, @jest-environment, --environment, [test] environment. If the package isn't installed, or the name is another one (jest-environment-jsdom), Bun prints a warning and runs the file without a DOM, even when the project sets environment.
  • DOM nodes print as markup in failure messages and snapshots, and compare with isEqualNode(). Before, expect(a).toEqual(b) passed for any two jsdom elements of the same class, and a failing not.toEqual on two of them took 51 s and printed 859 MB.
  • console.log, Bun.inspect and bun:test no longer walk non-enumerable properties.
  • node:vm: a DONT_CONTEXTIFY context is its own global, a bare identifier reaches accessors and Proxy traps with globalThis as the receiver, and programs run with a Proxy in the global's prototype chain. The newest jsdom works, including inline <script>. Fixes vitest jsdom environment with jsdom 30.1.0: forks worker fails to start under bun (EventTarget brand check) #43671, node:vm rejects a Proxy in a DONT_CONTEXTIFY global prototype chain, breaking jsdom with Vitest vmThreads #42331.
  • node:vm: when the contextified object is a Proxy or inherits from one, its names read as they do in Node.
  • Bun's builtin modules no longer depend on globals a DOM package replaces. With jsdom's or happy-dom's globals installed, perf_hooks, os.availableParallelism(), http, zlib, child_process and fs broke, and stream/consumers and $`...`.blob() created the DOM package's Blob.
  • With a DOM environment, reportError() dispatches an error event at the window, as in a browser. React 19 reports through it, so a test's window.addEventListener("error", …) now hears what a component threw.
  • Not yet: if an event listener throws while the function passed to toThrow() is running, that error counts as thrown by the function. Errors passed to reportError() have always worked that way. So expect(() => button.click()).toThrow() passes where Vitest fails it.
  • jsdom: instanceof Uint8Array (the window's class) is true for a Buffer and what TextEncoder, fetch and fs return. So for the other typed arrays and ArrayBuffer.

Runner

  • bun test --react-compiler, or [test] reactCompiler (true, folders or globs, or { include, exclude }), compiles components and hooks with Bun's React Compiler, but not test files, preloads or node_modules. Experimental.

  • The React Compiler, in bun build too, compiles what babel-plugin-react-compiler does: a component or hook nested in a function or block, memo(forwardRef(fn)), const A = function b() {}, any "use memo" function, JSX spreading { [key]: value }, and loops without a test.

  • A test file whose load can never finish now fails at once and names the stuck modules. It spun at 100% CPU.

  • The runner sleeps while it waits for a test or a hook. It polled with a zero timeout when nothing kept the event loop alive.

  • A --parallel worker that crashes while it has no file (after its last one) fails the run. The first catch: a worker with output left to send ran its event loop without the API lock.

  • --reporter=junit: the earlier attempts of a retried test are written as Surefire's <flakyFailure> (it passed in the end) or <rerunFailure> (it never did). <failure> stays the last attempt. A timeout is a TimeoutError. Attempts that fail without an error, like expect.assertions(), are included.

  • --reporter=junit: every <testsuite> has a timestamp, the time its file started. A file with no tests gets an empty <testsuite>. An error that a running test didn't throw itself, such as one from a timer, has uncaught="true".

  • Inside AsyncLocalStorage.run(), a hook was taken to have a done parameter and timed out, and test.each threw bind() called on non-callable.

Speed of --isolate and --parallel

  • --isolate=modules, a cheaper kind of isolation. Each file gets its own modules, module mocks, preloads and DOM window, and what it left open is closed, as with --isolate. The global object and the builtin modules are shared, so a file runs the code that was compiled for the files before it.
    • What a file does to what is shared is undone: properties added (non-configurable ones too), changed or deleted on globalThis, on the builtin classes and their prototypes, process.env, process listeners, the working directory. State inside a builtin module that no property shows is not.
    • The DOM package is loaded into the one global, so composedPath() instanceof Array is true.
    • It costs 0.1 s and 15 MB per process.
  • With --isolate, jsdom / happy-dom is loaded once per process, into a global that outlives the test files, and each file gets a new window from it, as in Vitest's vmThreads / vmForks pools.
    • 320 files of 20 DOM tests, 16 workers on 16 cores, jsdom 30.0.1: 23.9 s and 378 s of CPU before, 11.8 s and 184 s now.
    • The price: instanceof Array, Function or Promise is false for what jsdom's own code creates (composedPath(), getAttributeNames(), mutation records, the DOM methods). vmForks answers false there too. toEqual, toStrictEqual, toThrow(TypeError) and instanceof HTMLElement are unchanged.
    • The timers jsdom sets for itself (input.select(), focus(), postMessage()) are real while fake timers are on, as in vmForks. They no longer count in vi.getTimerCount().
  • Without --isolate, the window's names stay on globalThis until a file needs another environment. Redefining hundreds of names per file made JSC stop caching global reads: reading Array took 13 to 27 ns, now 0.2 ns.
  • Function.prototype.call.bind(f) cost 60 ns a call, and jsdom up to 25 has one in the brand check of every DOM method. The DFG now inlines f: 3 to 8 ns.
  • A RegExp with a group like (?:a|b?)+ ran in JSC's interpreter. jsdom up to 25 validates every selector with one: 8 selectors took 347 µs, now 1.1 µs.
  • Bun.ModuleGraph: graphs with the same globals names share the JIT code of CommonJS modules, as they do for ES modules.
  • bun test no longer ignores --cpu-prof, --cpu-prof-md, --heap-prof and --heap-prof-md. With --parallel, each worker writes one.
  • JavaScriptCore's code cache was capped at 2,000 entries. A bigger suite re-parsed most modules for every file. The cap now grows with what the files load.
  • With --isolate, Bun's own JavaScript modules (node:stream, node:zlib) are compiled once per process, not once per file.
  • setTimeout(callback, 0) doesn't wait for Node's minimum of 1 ms in bun test. It keeps its place among the timers and runs as soon as the event loop would sleep.
  • A static import is resolved once, not once per module that waits on it.
  • Source returned by a plugin is transpiled once per process until its text changes.
  • Registering a plugin no longer takes every file off the concurrent transpiler.
  • Test files that import from "vitest" now use the transpiler cache. bun run and bun test still share entries, except for files that use the test APIs.

Setup. Release builds of main (620b50f) and this branch (8548fe4). 320 synthetic files. Each loads about 2,340 modules (jsdom, react-dom, testing-library, 1,180 of its own) and runs 5 light tests. Medians of 3 interleaved rounds, 16 pinned cores, warm transpiler cache.

Per test file under --isolate, (40 files - 8 files) / 32:

Before After Change
instructions 2.36 G 1.30 G -45%
CPU time 728 ms 472 ms -35%

All 320 files, --parallel=16:

Before After Change
wall time 19.7 s 11.3 s -43%
CPU time 268 s 165 s -38%
peak RSS, all processes 5.09 GB 5.96 GB +17%

Vitest 3.2.7 on the same 320 files, 16 workers:

This branch, --parallel=16 vmThreads forks
wall time 11.3 s 45.3 s 71.6 s
CPU time 165 s 440 s 740 s
peak RSS, all processes 5.96 GB 18.9 GB 3.38 GB

Mock functions, instructions per operation:

Before After Change
a call 1,227 852 -31%
fn(implementation) and one call 2,883 3,228 +12%
spyOn() and mockRestore() 2,204 2,715 +23%

What got slower or bigger. Instructions unless a unit is given.

Before After Change
peak RSS of one process under --isolate, suite above 363 MB 436 MB +17 to 20%
passing toEqual on two class instances 89 ns 100 ns 133 ns once a DOM library is loaded
toBe 945 1,035 matcher calls are 5 to 17% slower
toSatisfy 960 1,465 +53%
.resolves on a pending promise 3,745 7,487 2x
.rejects.toThrow() 8,018 11,394 +42%
toThrow() on an async function 8,857 14,871 +68%
3 million uncached reads of Math.max in a contextified node:vm context 1.31 G 1.73 G such reads are 17 to 33% slower
one read of import.meta.env.DEV under bun test 25 558 SSR 1,116, a variable that is set 583
one read of an import from a mocked module 14 1,356 1,776 through a barrel's export *
one top-level import, mocks hoisted or a Vitest test file 141,000 209,000 +48%
peak memory, 100 files that each mock a module and import 300 others 30 MB 100 MB flat in the number of files

Plugins and loaders

  • A runtime onLoad can decline by returning undefined or null, sync or async. The file then loads as if no plugin matched.
  • onLoad gets the file's path for the original of a mocked module. It got file.ts?actual, which a filter ending in $ doesn't match.
  • onResolve is called for every specifier, including bare ones. Fixes Runtime Bun.plugin onResolve does not fire for bare package specifiers #44603 and the runtime half of Runtime plugin onResolve never fires for bare or virtual specifiers (onLoad works) #40579 and Bun runtime plugin onResolve doesn't filter non-file: protocol imports #9863.
  • contents returned by a plugin loads like a file with the same bytes: json, jsonc, json5, toml, yaml, text, css, CommonJS, UTF-8 bytes, ArrayBuffer. Fixes the CommonJS half of Bun.plugin() cannot safely instrument JavaScript: onLoad() breaks CommonJS #39786.
  • Importing a .module.css at runtime gives the bundler's class names. The file is parsed the way a browser parses CSS: invalid parts are skipped and never fail the import.
  • Vite's import suffixes work at runtime: ?url, ?raw, ?worker, ?sharedworker, ?worker&url, ?init on WebAssembly, and ?inline. On CSS, ?inline gives the text. On an asset it gives a data: URL, base64 or Vite's shorter form for .svg.
  • Under bun test, import.meta.env has MODE, DEV, PROD, SSR and BASE_URL, and reflects vi.stubEnv. VITEST_POOL_ID and VITEST_WORKER_ID are set.
  • import.meta.glob works at runtime, in Bun.build and in the dev server. Fixes Implement import.meta.glob #6060. Not yet: under --watch, --hot and the dev server, a new file that matches the pattern doesn't trigger a reload until the importing file changes.
  • Fixes a bun build stack overflow on a composes cycle.
  • Not yet: in a bundle and in the dev server, a file imported with two loaders (import.meta.glob("./x/*.ts") next to the same pattern with query: "?raw") gets the loader of the first import.

Older bugs the new tests ran into

  • After script read error.stack, a folder named in parentheses cut the printed frame short: at /app (group:1:1).
  • FormData: get(), getAll() and the iterators return the File that was appended, the same object each time, as in Node and browsers. append(name, file, "other.txt") kept the file's old name.
  • The JUnit reporter wrote time="0." for a test faster than 0.5 µs, and U+FFFE and U+FFFF, which XML 1.0 forbids.
  • expect.any(URL) was printed as Any<Function>. A Response in a diff was followed by Response {}.
  • The JUnit reporter left out errors outside of any test: a test file or preload that throws while it loads, has a syntax error or imports a missing module. Each is now a failed <testcase> named (unhandled error). In a test, those two had an empty <failure>.
  • After a worker crash, SIGINT or SIGTERM, --parallel's JUnit report left out the files that never started and showed no failure for those cut short. They now have a failed (aborted) or (interrupted) test case.
  • N tests failed: didn't list a file that failed to load. --reporter-outfile creates its folder.
  • The React Compiler dropped text that the parser had joined onto a template literal: `${a}-` + "b" was compiled to `${a}-`. That hit bun build --react-compiler with --minify or --minify-syntax, and the argument of import() without them. A computed key like ["a" + "b"] no longer leaves its function uncompiled.
  • A data: module with a . in its text lost its exports, under bun run too. data:text/javascript,export const a = 1.5; was loaded as a plain file.
  • Windows: a file written after its directory was first listed stayed invisible to the resolver if the specifier spelled the directory differently.
  • An unhandled error that arrived while the next test file was loading dropped that file's tests.
  • Printing an error whose message getter throws left the exception pending, and every later test in the file was reported as passed without running.
  • If a test file failed to load, an async beforeAll() / afterAll() from a preload that was due with it was started but not awaited.
  • A retry reset the toMatchSnapshot() counters of every test, not just its own.
  • fs.promises.watch(url) threw. A URL from another realm or implementation was rejected by fs.createReadStream, fs.watch, fs.cp, fileURLToPath, http.get, new Worker and child_process's cwd, and Bun.SQL(url) treated it as an options object.
  • With retry, leftovers of an earlier attempt (a late done, a settle after a timeout) could end the next one early, so a failing test passed. A rejection after a timeout is ignored, as in Vitest and Jest.
  • After reportError(), done(error) or an uncaught error, the next test started on top of the call stack that reported it.
  • expect() and test() in a Worker used the main thread's runner from the worker's thread and crashed.
  • toMatchObject wrote the asymmetric matchers into the received object, and passed when one object appeared twice with different patterns.
  • --isolate with any plugin registered: macro calls returned undefined from the second test file on.
  • --isolate: a test file that an earlier one imported parsed its .png imports as TSX.
  • A rejected onResolve was swallowed, and a rejection value with a path was used as the result.
  • In a ShadowRealm, process.nextTick() callbacks never ran, unhandled rejections weren't reported and were kept forever, and Bun.isMainThread was false.
  • The most recently created node:vm context was never collected.
  • bun build panicked on an unresolvable import path that isn't valid UTF-8.
  • setTimeout(f)._onTimeout inside AsyncLocalStorage.run() was an internal object instead of f.
  • process.stdout._handle.setBlocking() (and stderr's) didn't exist, so fs.writeSync(1, big) into a pipe stopped at 64 KiB in tools that rely on it.
  • bun -c=<path> ignored the path.
  • --parallel workers didn't read the --config file, only ./bunfig.toml.
  • --parallel could print a file's logs or a failure's error under the next file's name. Fixed on macOS and Linux.
  • A test file with a decorated member typed jest.Mocked<T> (with emitDecoratorMetadata) failed with jest is not defined.
  • --isolate: a BroadcastChannel, or any other object with pending activity, left open in a ShadowRealm kept that realm alive for the rest of the run.
  • A static import never settled if the module's top-level fetch had been rejected with another module's error.
  • When two mocks patched the same variable, exported by two modules through export * from, they were undone in hash-map order. So a mock's value could leak into the next test file.
  • With a preload that loads modules, a mock could leak into the next test file through a CommonJS module, through require() of an ES module, and after jest.resetModules().
  • A tag got the escaped form of non-ASCII text in strings.raw: String.raw`😀` was \u{1F600} at runtime and with --target=bun. The REPL and Bun.Transpiler still escape it.
  • --expose-gc and v8.setFlagsFromString("--expose-gc") also define gc in node:vm contexts created afterwards, as in Node.
  • A RegExp whose source is over 1 MiB compiles if what's outside its character classes is under 1 MiB. It was a SyntaxError.
  • A search with a repeated group inside a lookaround, like /(?=((?:ab|c)+))\1x/, left the RegExp JIT after about 1,000 characters and ran 100x slower. From 4,096 it returned null for a string that matches.
  • A decorator in a CommonJS TypeScript file (.cts, or one that assigns to module.exports) was a SyntaxError.
  • toHaveProperty("a.b") threw a TypeError where a is undefined or null.
  • node:http2 server: a stream closed or ended before respond() sent DATA with no HEADERS. The client's protocol error ended the session's other streams too.
  • node:http2 server: stream.close(code) on an open res sent END_STREAM before RST_STREAM, so an aborted response looked complete.
  • node:http2: session.destroy() emitted close before the socket closed, and left it open if the peer never closed it.
  • node:http2 client: a stream reset with NO_ERROR before any response emitted close without end if nothing read it. close(NGHTTP2_CANCEL) could send RST_STREAM twice.
  • node:http server: res.destroy() or req.destroy() before anything was sent, once the listener had returned, sent 200 OK instead of closing the socket.

Security

A host function called by a bare name gets the scope object as this. Functions that return this for chaining handed it to user code: console.log(setSystemTime(0)) printed [native code: JSModuleEnvironment]. Fixed in the test module, in Bun.plugin's onLoad / onResolve / module, and in 17 [nodejs.util.inspect.custom] functions.

Behaviour changes for existing users

Matchers

  • Code after an un-awaited .resolves / .rejects now runs first. The test still waits for the matcher, and a failure is still that test's. Here, 10 of the 240 test files that use these matchers relied on that. The fix is always to add await. The same goes for toThrow on an async function and async custom matchers.
  • An un-awaited matcher outside a test (at the top level of a file or in a describe body) is awaited at the end of the file. If it fails, it's reported as that file's "Unhandled error between tests".
  • A custom matcher's message no longer has Bun's own signature line above it.
  • expect.stringMatching("a.c") compiles the string as a RegExp, as in Jest and Vitest, and throws its SyntaxError at once. It looked for the substring.
  • .not read again on an expect() that was kept in a variable negates again, as in Jest and Vitest. It toggled, so the second kept.not.toBe() wasn't negated. .not.not in one chain still cancels out in bun:test.
  • expect.extend() ignores default and __esModule entries that aren't functions.
  • toMatchObject and snapshot property matchers no longer modify the received object.
  • asymmetricMatch() and this.equals() throw if they hit a promise that's still pending. They used to block until it settled. Jest and Vitest don't support this either.
  • Once a test has ended (timeout, failure, retry), matchers it's still waiting on don't run. Their promises reject, so finally blocks and await using in the test function still run, but statements after the await that aren't in a finally don't.
  • .resolves and .rejects compare once, when the promise settles, using the values as they are at that moment, as in Vitest.
    • If a matcher hits another promise partway through a comparison (an async custom matcher inside toEqual(), or an asymmetric matcher or equality tester that returns a promise), it's re-run once that promise settles, and the re-run has to make the same comparisons on the same values.
    • Getters and Proxy traps on the compared values run one more time for each promise it waits on.
    • If the compared values changed in the meantime, or a getter returns a different object or promise on each read, the matcher fails and says so.
  • These print with their properties, not as an empty Module {}, JSGlobalProxy {}, ProcessEnv {} or DatabaseSyncLimits {}: a module namespace object, the global of a node:vm context, process.env shared through SHARE_ENV, and node:sqlite's db.limits. Affects snapshots, failure messages and Bun.inspect(value, { sorted: true }).
  • Failure messages and snapshots call a value's own enumerable getters and print the result (total: 5), as Jest does, not [Getter] or [native code]. So a lazy getter (an ORM relation) runs when an assertion on its object fails, and counts as covered. One that throws still prints as [Getter].
  • A React 19 element prints as JSX, as React 18's do, not as an object with the whole fiber tree under _owner. One failed toMatchObject on a component's props could cost seconds, each time waitFor retried it.
  • A failure message stops going deeper after 1,000,000 characters, as in Vitest. toEqual on an object holding a fiber: 80 s to 63 ms.
  • this.equals(a, b, customTesters, true) respects its fourth argument and compares like toStrictEqual(). It used to be ignored: this.equals(new A(), { x: 1 }, [], true) was true.
  • Two different functions are never equal for toEqual(), toStrictEqual(), Bun.deepEquals(), util.isDeepStrictEqual() and node:assert, even if they're mock functions or native constructors, as in Jest, Vitest and Node. Any two mocks used to be equal, so expect({ cb: mockA }).toEqual({ cb: mockB }) passed.
  • If the function passed to toThrow() reports several errors instead of throwing (reportError(), throwing listeners), only the first counts as thrown. The others are reported and fail the test.
  • An equality tester or snapshot serializer added by a module that test files import stays registered for every later file, like one added by a preload. Only one added by the test file's own code is removed when that file ends.

Between test files, without --isolate

  • Module mocks, spies, fake timers, setSystemTime, vi.stubEnv and vi.stubGlobal no longer leak into the next test file. What a --preload script (or its beforeAll()) created or stubbed stays for every file. Its fake timers and system time are kept until a test file calls useFakeTimers(), useRealTimers() or setSystemTime().
  • A mock function or spy shared by several test files starts each file with no recorded calls, and spies still installed are restored at the end of the file. Implementations set with mockReturnValue() and similar stay until the mock is reset.
  • With --isolate or --parallel, --rerun-each also undoes these between runs of the same file.

Mocks

  • vi.mock(path) / jest.mock(path) without a factory automock instead of throwing.
  • A hoisted jest.mock() / vi.mock() factory that runs during the file's imports can no longer read a top-level const / let from that file, unless it's initialized with a plain literal. It's a ReferenceError with a note, as in Vitest and Jest. Use vi.hoisted(), or read the variable lazily.
  • If the module was loaded before the file, that factory runs at the first import. Modules that a preload loaded still see the original, as in Vitest. They used to be patched.
  • require() of a mocked module returns the factory's own object, for a builtin too (it used to get the real one). Until an async factory that awaits has settled, it returns the original.
  • An ES module with a hoisted vi.mock() / jest.mock() awaits its imports. So require() of it throws unless it was already imported. Use await import() or CommonJS.
  • vi.fn() and vi.spyOn() used to be aliases of jest.fn() and jest.spyOn(). Their mocks now behave like Vitest's:
    • mockReset(), mockRestore() and resetAllMocks() go back to the implementation passed to vi.fn(impl) (for a spy, the original method) instead of undefined
    • vi.restoreAllMocks() keeps the calls and implementations of the spies it restores
    • fn.mock is the same object after mockClear()
    • withImplementation(impl, callback) returns the mock
    • getMockName() is "vi.fn()", or the property's name for a spy
  • mock.instances is filled in. Restoring a spy on an inherited method deletes the own copy.
  • getMockName() on a jest.fn() or jest.spyOn() mock is "jest.fn()", as in Jest. It used to be the name of the function ("foo", or "mockConstructor" if it had none) or of the method. .name is unchanged, and so are mock() and spyOn() from bun:test. A mock of an arrow function has its .name.
  • spyOn(namespace, "notAnExport") throws.
  • import { f } from a mock.module() mock whose factory doesn't return f now links, and f is undefined, as with Jest's interop. It used to be a SyntaxError at link time.
  • An entry in mock.results is completed in place, as in Jest. One that was read while the call was still running changes from incomplete to return or throw. It used to be replaced by a new object.
  • jest.clearAllMocks(), jest.resetAllMocks(), jest.restoreAllMocks() and the same three on vi return jest / vi instead of undefined, as in Jest and Vitest.
  • A factory is called once the load that asked for its module has fetched all it can. An unrelated load doesn't delay it.
  • spyOn(import.meta.env, name) spies on process.env, as it did when the two were the same object. For one of Vite's five variables that process.env doesn't have, the spy stands in for a missing property. It reads undefined (false for DEV, PROD and SSR) until it's given a value, and the "get" and "set" forms throw.
  • spyOn() on the global object of a node:vm context spies on the contextified object's own property, and the spy is called. If that object is frozen, it throws a TypeError instead of installing a spy that's never called.
  • A spy that can't be restored at the end of a test file is reported once, for that file, and isn't retried.
  • If a mock is removed or replaced while its factory is running, the module is loaded in its current form as soon as it, or a module waiting on it, is imported again.
  • jest.resetModules() inside a factory no longer cancels the mock being created.
  • A test file that mocked modules is replaced in the registry by a module holding the values it exported when it ended. A later import of it sees those values, which no longer change.
  • Modules loaded by a preload are the same instance in every test file, whatever an earlier file reset.
  • A module imported under two types is reloaded, not patched, when it's mocked or unmocked.
  • A module that imports a mock whose factory failed with another module's error now fails too, instead of never loading.
  • A mock made by vi.fn() is named Mock, as in Vitest, whatever implementation it's given. It used to be named after the implementation, or mockConstructor. Instances created with new on such a mock print as Mock {}. jest.fn(), mock(), spies and automocks keep their names.
  • A mock that user code deleted from require.cache no longer leaks into the next test file through its importers.
  • vi.mock() and vi.doMock() in a test file no longer change a module that a --preload script has loaded, or what that script's own imports see, as in Vitest. The file's imports get the mock, and the next file finds the preload's modules unchanged. mock.module(), jest.mock() and jest.doMock() still patch them.
  • A module mocked by a top-level jest.mock() or vi.mock() before anything imported it is loaded as the mock, and exports only what the factory returns. Before, the call ran after the file's imports and patched the loaded module, which kept its other exports.

Tests registered through "vitest" (the file imports from "vitest", or [test] globals = "vitest")

  • In a test file or preload with two or more imports, a name that the module doesn't export is undefined, as in Vitest, not a SyntaxError.
  • The first parameter of a test is its context, not a done callback. Calling it throws Vitest's done() callback is deprecated, use promise instead.
    • A test that relied on done now ends when its function returns. Work it still does in a timer or callback isn't waited for. A failing expect() there is reported against whatever runs at that moment, or not at all once the run has ended. A test that never calls done no longer times out.
    • Vitest reports such a late failure as an unhandled error of the run. Bun ends the run without waiting.
    • "vitest" used to be an alias of "bun:test", where done worked. Return a promise instead.
    • After-hooks run in reverse order.
    • A file that only uses the global vi keeps bun:test's test, describe and hooks.
  • The calls and results of all mock functions are cleared before each attempt, which is Vitest 5's default. Turn it off with [test] clearMocks = false or vi.setConfig({ clearMocks: false }).
  • Hooks time out after 10 s (or --timeout, if longer), not after the test timeout.
  • vi is no longer the same object as vi from "bun:test". Its useFakeTimers() also fakes setImmediate and clearImmediate, so a test that waits for a real one hangs. doNotFake keeps them real. vi and jest from "bun:test" leave them real, as before.
  • .not.toMatchObject() passes when the received or the expected value isn't an object, as in Vitest. In a bun:test test it's still a matcher error, as in Jest.
  • Arrays of different lengths are never equal, as in Vitest: expect(["a"]).toEqual(["a", undefined]) fails, in every matcher that compares like toEqual. In a bun:test test it passes, as in Jest.
  • Of two errors, cause is compared only if the expected one has it, and the errors of an AggregateError are compared, as in Vitest.

Runner

  • Some test.each titles change (%o used to print JSON, and %p of an object spanned several lines). That changes snapshot keys and what -t matches.
  • Through "vitest" (an import from "vitest", globals = "vitest", or test.for / describe.for from any module), the titles of .each and .for tests and describe blocks match Vitest 5's.
    • Values print as { a: 'x' }, [Function named], Map { 'k' => 1 }, truncated at about 40 characters ([ 0, 1, …(118) ], { …(1) }).
    • %% consumes a value, and if printing a value throws, test.each()() throws that error.
    • A non-string name is converted the way Vitest does it (<anonymous> for an unnamed function). So for every test and describe block of that flavour.
    • "bun:test" and "@jest/globals" keep their titles.
  • After an uncaught error, reportError(), done(error) or a timeout ends a test, the next test starts in a new event-loop task. Its stack traces no longer contain frames from the previous test.
  • Environment comments used to be ignored and are now honored.
    • @vitest-environment <name> counts anywhere in the file's text, even inside a string, as in Vitest.
    • @jest-environment <name> counts only in a /* */ or /** */ comment at the very start of the file, as in Jest. A // comment doesn't count, and neither does one after a shebang or another comment.
    • If jsdom / happy-dom resolves from that file, the file runs with that DOM. With jsdom the typed arrays belong to another realm (Buffer.from("a") instanceof Uint8Array is false), as under Vitest.
    • If the package isn't installed, the file runs as before, with one warning.
  • test() and the hooks throw in a Worker. expect() there behaves as it does outside bun test.
  • An async error while a test file loads no longer drops its tests: an unhandled rejection, a throw in queueMicrotask(), process.nextTick() or a timer, or reportError(), also from an imported module. It's still reported and the exit code is 1, but the file's tests run. A top-level throw or rejected await still fails the file without them.
  • environment, globals, clearMocks, mockReset, restoreMocks, unstubEnvs and unstubGlobals in [test], and junitSuites in [test.reporter], used to be unknown keys and were ignored. A value of the wrong type or an unknown name now stops bun test with Invalid Bunfig.
  • --environment and --reporter-junit-suites take a value. bun test ignores flags it doesn't know, so a leftover bun test --environment used to run everything and is now an error. Vitest's --globals, --globals=true|false and globals = true|false still change nothing.
  • onTestFinished is a new global in test files.
  • A skipped test with repeats prints as (skip) name, not (skip) name (run 2).
  • test.failing and test.fails only count what the test's own function throws, rejects with or passes to done() as the expected failure. An uncaught exception or unhandled rejection that arrives meanwhile is an unhandled error between tests and no longer makes the test pass, as in Jest 30 and Vitest 5.
  • done(error) in a concurrent test fails that test. It used to be printed as an unhandled error between tests while the test itself passed.
  • Hooks in a skipped or todo describe block are no longer printed and counted as tests. The skip / todo totals, Ran N tests and JUnit's tests / skipped go down.
  • JUnit: <failure> for .failing(), expect.assertions(), expect.hasAssertions() and a passing todo now has type before message.
  • In a test file that imports from bun:test, @jest/globals or vitest, the other test globals (describe, expect, ...) were a ReferenceError unless something put them on globalThis. They're now injected as in any other test file. A name a preload put there still wins.
  • jest, xit, xtest and xdescribe are no longer defined under the Vitest flavour, as in Vitest. That means a test file or preload that imports from "vitest", every module loaded for either, and everything under globals = "vitest".
    • Library code used to see them under every flavour, so @testing-library's waitFor and findBy* advanced a faked clock. Now they leave it alone.
    • To get that back, use vi.useFakeTimers({ shouldAdvanceTime: true }) or put a jest object with advanceTimersByTime on globalThis.
    • A module keeps whatever it was first loaded with. So without --isolate, set globals = "vitest" if the project mixes flavours, or if its preload loads such a library without importing from "vitest".
  • console.timeStamp isn't defined in tests, as in Jest and Vitest. With it, React's development build reads into props, and a getter that throws breaks every later render.

Timers

  • node:timers and node:timers/promises are never faked, as in Vitest and Jest.
  • advanceTimersByTime(0) and advanceTimersByTimeAsync(0) no longer move the clock by 1 ms. A fake setTimeout(fn, 0) fires at the time it was set, not 1 ms later, so a timer it sets is due 1 ms earlier than before. setTimeout(fn, 1) no longer fires on an advance of 0. A fake setInterval(fn, 0) still waits 1 ms.
  • advanceTimersByTime, runAllTimers and friends rethrow what a callback threw. runAllTimers() with a setInterval throws at the loop limit instead of never returning. advanceTimersToNextTimer() runs every timer due at the next instant.
  • Promise jobs no longer run in the middle of a test function. In the first tests of a file, a native call that runs a callback (advanceTimersByTime() firing a timer) ran every pending promise job before it returned. In later tests it didn't. Now it never does, as in Jest and Vitest. The …Async controls run them between timers.
  • With a DOM on globalThis, useFakeTimers() fakes requestAnimationFrame and cancelAnimationFrame by default.
  • At the end of a test file, the promise of a *Async() timer call that's still in flight stays pending, as in Vitest.
  • Under bun test, delete process.env.TZ goes back to Etc/UTC instead of the host's time zone.
  • A second useFakeTimers() starts a new clock and drops timers pending on the first one. They used to stay.
  • vi.useFakeTimers() starts the fake clock at an earlier setSystemTime(x), else at what Date.now() returns, spy or not, as in Vitest. jest.useFakeTimers() starts at the real time.
  • runOnlyPendingTimers() leaves the clock at the deadline of the last timer pending at the call, even if that one was cleared meanwhile. It used to stop at the last that fired.
  • setSystemTime() with a string that isn't a date throws a TypeError. It was accepted before.
  • advanceTimersToNextTimer(steps) takes Jest's steps, which was ignored, and throws a TypeError unless it's a number from 0 to 2^32-1.
  • With process.nextTick or queueMicrotask faked, an error from such a callback ends the function that was advancing the clock. So does the loop-limit error when callbacks keep queueing each other. A sync call throws it, and an async one rejects with it. A self-advancing clock reports it once and goes back to manual mode.
  • Real timers due in the same millisecond keep their creation order within 8.4 million timers. It was 16.8 million.
  • jest.spyOn(globalThis, "setTimeout") under fake timers, restored after useRealTimers(): setTimeout no longer keeps its own clock property for the rest of the run, which made @testing-library think the timers were fake. The real function is back at the next useFakeTimers() / useRealTimers() pair or at the end of the file.

Snapshots

  • A .snap file whose first line names Jest or Vitest is now read and written in that tool's format. Entries Bun had appended to such a file in its own format, under its own keys, keep passing. A Jest file whose keys are still sorted has no such entries and is compared in Jest's format only.
  • An inline snapshot in a bun:test test also passes if it contains what Jest or Vitest would have written for the value. So a few that used to fail now pass: "5 EUR" for an object whose toJSON() returns that string, [Function] for a class, "boom" for a thrown { message: "boom" }.
  • When several .resolves.toMatchSnapshot() or .rejects.toMatchSnapshot() calls are pending at once, they're numbered in the order their promises settle, as in Jest, not in call order. Await each one to keep the numbering independent of timing.
  • With property matchers, if the same object appears in several places in the received value, each place is printed using the pattern that applies there, as in Jest and Vitest. If a shared object is covered by a pattern in only one place, a snapshot an older Bun wrote for that value has to be updated.
  • In a Jest-format or Vitest-format snapshot, a value nested deeper than 4,096 levels of indentation throws RangeError: Maximum call stack size exceeded. It used to print down to about 5,000 to 11,000 levels, depending on the kind of value. The tools themselves give up before 2,400.

Printing

  • console.log and Bun.inspect print fewer properties for an object that has non-enumerable ones, as Node does.
  • If reading a value's [nodejs.util.inspect.custom] throws, Bun.inspect() and console.log() now rethrow that even when the value is the first property of an object. It used to print <Revoked Proxy> there, and already threw everywhere else.

Loading

  • Importing a .module.css at run time used to give {} without opening the file. It now gives the class names Bun.build gives, as the default export and as named exports. The namespace no longer has __esModule: true.
  • An import path with one of Vite's suffixes in its query (?url, ?url&v=1) means what it means in Vite. The query used to be ignored. An import attribute or a plugin still takes priority. Other queries are unchanged, and so is bun build.
  • Under bun test, import.meta.env is no longer the same object as process.env. It's a view of it with five more names, and a DEV / PROD / SSR from the environment reads as a boolean. Passed to a child process, the five names go along as strings, and PROD=false reads as true in a nested bun test. Pass process.env instead.
  • An import.meta.glob() call that can't be expanded throws a TypeError that says why, when the call is reached. import.meta.glob = turns that off for the file. As in Vite, eager: true imports the matched files when the module loads, even if the call is never reached, and bun build expands the calls.
  • A broad onResolve filter now sees packages, extensionless paths, and prefixed specifiers like virtual:thing (unless an onLoad in that namespace matches). An onResolve that rejects fails the import with that error. A NUL in a path or namespace returned by a plugin is ERR_INVALID_ARG_VALUE.
  • Under --isolate, a file that calls a macro or import.meta.glob is transpiled once per test file.
  • Under bun test, import("vitest"), require("vitest"), require.resolve(), import.meta.resolve() and Bun.resolveSync() give the built-in module even when the real package is installed, as a static import already did. The same goes for "@jest/globals". bun run is unchanged.
  • bun:test/vitest is a new built-in specifier, also under bun run. It's what "vitest" resolves to under bun test.
  • import.meta.resolve("pkg?raw") keeps the ? of a query after a package name. It used to be escaped as %3F.

Builtin modules, also under bun run

  • new Blob(), new File() and slice(): type is the type the script gave, in lower case, as in Node and browsers. text/plain used to come back as text/plain;charset=utf-8, and likewise HTML, CSS, JSON, JavaScript and form data. A Content-Type header made from such a blob still names the charset.
  • Builtin modules no longer see a global that user code has replaced, as in Node. util.TextDecoder, url.URL, http.WebSocket, perf_hooks.performance, crypto.webcrypto and the like are always Bun's own, and so is whatever builtins create. A replaced setTimeout no longer drives net, http or child_process timeouts.
    • This matters if you polyfill a web class or register a DOM by hand (GlobalRegistrator.register()). The Blob from $`...`.blob() or stream/consumers is no longer an instance of the replaced Blob.
    • happy-dom's FormData.append() and FileReader reject it, and its new File([blob]) and new Response(blob) silently hold the text [object Blob].
    • Convert it first: new Blob([await blob.arrayBuffer()]).
  • A URL that has been given an own path or auth property is treated as an options object by http.request() and https.request(), as in Node. The request goes to localhost:80 and that path, not to the URL's host.
  • A plain object with href and protocol, and without path and auth, is treated as a URL by http.request() and https.request(), as in Node. Its pathname and search are requested, where it used to be /. fs, Worker and child_process read these four properties from a non-string path argument, so getters and Proxy traps for them run.
  • url.fileURLToPath() on something that's neither a string nor a URL has Node's message, The "path" argument must be of type string or an instance of URL. The code is still ERR_INVALID_ARG_TYPE.
  • Native printers use Bun's own inspect, as Node does. That covers printing a BroadcastChannel, web streams, a PerformanceEntry or URLSearchParams, and the third argument and options.stylize of a [util.inspect.custom] method. A function assigned to util.inspect before the first such print used to be called instead.
  • Error.captureStackTrace(target) throws TypeError: invalid_argument when target is a Proxy, as Node does. It used to silently do nothing.
  • new Bun.CookieMap(object) takes the object's enumerable own properties, like a Web IDL record, and asks a Proxy for them. It used to take non-enumerable properties too, and found none behind a Proxy.

node:vm and realms

  • vm.isContext(vm.runInContext("this", ctx)) is false and vm.runInContext(code, thatGlobal) throws ERR_INVALID_ARG_TYPE, as in Node.
  • util.diff().
  • toEqual() compares the global of a node:vm context by its properties. Two contexts with equal contents are equal (they never were), and two DONT_CONTEXTIFY globals with different contents aren't (they always were).
  • --isolate: ShadowRealms and node:vm contexts created by a finished file are torn down with it. Their channels and ports are closed, their pending promise reactions are dropped, and their rejections are no longer errors of the next file.

Types

  • Mock<T> has a construct signature, so new mockFn() type-checks. TypeScript then sees a class component: <Button label="ok" /> with Button = mock((props: Props) => null) is TS2607, as with @types/jest and Vitest. Cast the mock to the component's type. An overload that checks for a constructor first picks that branch.
  • jest.clearAllMocks(), resetAllMocks() and restoreAllMocks() return typeof jest. The same three on vi, and vi.clearAllTimers(), return typeof vi. () => jest.restoreAllMocks() still fits every hook, but not a test() callback: use a block body. The fake-timer functions on jest return typeof jest instead of typeof vi.
  • .resolves and .rejects also accept a function. A helper that's generic in the value itself (expect(value as T).resolves.toEqual(expected as Awaited<T>)) is TS2769. Type the parameter as Promise<T>.
  • Loader has "json5" and "md", and OnLoadResult has null. An exhaustive switch or Record<Loader, …> needs the new cases, and code that calls an OnLoadCallback must handle null.
  • import.meta.glob is typed. A declaration from another package (vite/client) or from the project replaces Bun's, whichever is loaded first. With skipLibCheck: false, a hand-written ImportMeta.glob that isn't assignable to Bun's is TS2430 at lib.es5.d.ts. A // @ts-expect-error on an untyped import.meta.glob is now unused.
  • test(label, fn, options) with options typed any (test("x", done => done(), config.timeout)) keeps the callback's parameter types, which were TS7006 under strict. Tests made by test.extend() accept every form test does.
  • Hand-written objects for Test, Describe, a mock's state or a matcher's this.utils lack the new members. A project's own augmentation with the same name as a new export or matcher is a duplicate (TS2300).

Size

  • Five things offset part of the growth:
    • Shared::notify() in src/jsc/VmHandle.rs is #[cold] #[inline(never)]. It only runs at VM shutdown and was inlined into 252 places: 44 KB.
    • Four rarely run sorts go through bun_collections::index_sort.
    • The parser's generate_import_stmt is compiled once per parser, not once per caller.
    • 225 cold functions of the test runner are optimized for size: 75 KB. A failing toEqual() is 2.4% slower.
    • Matchers share one call_matcher, not 95 inlined copies: 40 KB. A matcher call runs 2.5 to 2.9% more instructions.
  • Text, path and number helpers are shared with bun_lint, bun_core and bun_node_path. One change to the linter: parse_int is exact at any length for a power-of-two radix, not just 32 hex digits.
  • import.meta.glob still treats parentheses as plain characters now that Bun.Glob has @(a|b) and similar. {1..3} and {a..c}, also new in Bun.Glob, work as in Vite.

Supersedes #44830, #44831, #44832, #41565, #38831, #35502, #34623, #42347, #38993, #44435.

How did you verify your code works?

The new tests fail on bun 1.4.3-canary and pass here.

  • Hoisting. Both binaries transpiled 32,641 files in bun run and bun test mode. Code, source map and module record are identical, except in files with top-level mock calls. A generator puts 19 kinds of scope-creating expression into every argument of every rewritten call: 58,884 cases, no crash.
  • Module mocks. Two generators mock a third or more of the modules in random graphs. In one, namespaces and bindings agree on all 150 seeds in any order. In the other, 799 of 800 graphs match Vitest 5.0.3, with and without --isolate. 3,000 random test files give the same result alone and in one run (52 didn't).
  • Against the originals. import.meta.glob: 87 calls, equal to Vite 8.3.4's. node:vm with a Proxy: 137 sandboxes x 79 programs against Node 26.3, 2,067 rows that differed are now equal. DOM node equality: 158 of 170 cases agree with Jest and Vitest.

Tests that count global objects now walk the heap. The --isolate leak tests in isolation.test.ts, plugins.test.ts and mock-module.test.ts counted the GlobalObject cells left after a GC and allowed one extra. They turned flaky on release builds. The extra cell was the file just finished, and nothing in the heap held it:

  • Heap snapshot. In generateHeapSnapshotForDebugging(), taken while the cell is retained, it has no roots entry, and its only incoming edges are from its own structures and functions.
  • Debugger session. The process reads its own stack from /proc/self/mem. Two words hold the cell's address, both in the frame of TestCommand::run (rbp-0xb0, rbp-0x110), the same in three runs. Hardware watchpoints on both show who writes them:
    • The first is written by Zig__GlobalObject__createForTestIsolation: the old global, in a frame that has returned. run never writes that slot.
    • The second is written by run where it builds Environment. Moving a struct whose Option fields are None copies their uninitialised payload: that word.
    • The next file overwrites both.

They now count what a root reaches, using Heap in test/harness.ts. They expect exactly one GlobalObject, and a failure shows the path to each extra one. They still fail with the bugs they were written for.

One new test also counts what a collection leaves. A leak the snapshot can't see keeps all ten old files, so it allows fewer than five. On Alpine x64 two survive, and no root reaches either. One word in TestCommand::exec (rbp-0x58) points at the second: a watchpoint shows a spilled mi_malloc result, then a bool written over its low byte.

Adversarial review. All 73 repros from fuzzers, GC stress and manual attacks failed when found and pass now.

@robobun

robobun commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 4:14 PM PT - Oct 31st, 2026

⏳ @Jarred-Sumner, your commit c0974df is still building in Build #136241, but has 41 failures so far (All Failures):

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Why isolation.test.ts now counts the graphs that roots hold

--isolate=modules keeps the compiled code of the last 8 test files. The test for "and no longer than that" used to check that a later file does not find the first file's code still compiled. That failed on x64 musl and x64 Windows (build 125809), because the first file's module graph was still alive there. Nothing in this PR holds it. These are the two artifacts that the repository's rule about the conservative stack scanner asks for. Both were taken with CI's bun-linux-x64-musl-profile of 2bbb5a0 on Alpine 3.23, in the last of 13 test files, after two Bun.gc(true).

1. Heap snapshot (generateHeapSnapshotForDebugging())

  • Walking from roots over all edges reaches 9 ModuleGraph cells through root(StrongHandles): the 8 that are kept and the running file's.
  • One more ModuleGraph (0x392557ebe88) is in the snapshot and is not reached. Walking its incoming edges backwards gives a closed set of 43 cells: the graph, its loader, module records, environments and the file's functions. No cell of the set has a roots entry, and no cell outside the set has an edge into it.

2. The stack, read from /proc/<pid>/mem while that test sleeps in Bun.sleepSync (rsp = 0x7ffddd5c06e8). Every word from rsp to the base of the stack was compared with the address ranges of the 43 cells. One word matches:

0x7ffddd5c0738  0x21913a7      return address in BunObject_callback_sleepSync
0x7ffddd5c0888  0x49211c4      return address in llint_call_javascript
0x7ffddd5c0908  0x501fa5d      return address in JSC::Interpreter::executeCall   (its frame is above this)
0x7ffddd5c0940  0x39258055e00  the JSFunction `esm` of the first file, offset 0
0x7ffddd5c0a28  0x1bb4da6      an address in Bun::moduleGraphOfFrame, which is not running now
0x7ffddd5c0a68  0x1eefec6      return address in Bun__JSValue__call              (executeCall returns here)
0x7ffddd5c0b78  0x248151f      return address in BunTest::run_test_callback
0x7ffddd5c0c78  0x267ffc2      return address in test_runner::execution::step_sequence

The word is in the live frame of JSC::Interpreter::executeCall, in a part that this call has not written. The same frame still holds a return address into Bun::moduleGraphOfFrame, left by an earlier call that reached that depth.

It only happens when the first file imports bun:jsc, an ES module and a CommonJS module. A hot loop is not needed, and none of the JIT and GC options that were tried changes it.

The test now asserts what this code is responsible for: after 13 files, exactly 9 graphs are held by strong handles.

The same for "only the last two finished files can still be waiting for garbage collection"

On x64 musl (build 125970, 37d76cc) the sixth of 13 files stayed alive under --isolate, in every run. It started with 5641578, which grew the frame of TestCommand::run from 0x498 to 0x4c8 bytes. Taken with CI's bun-linux-x64-musl-profile of 37d76cc on Alpine 3.23, in the last file, after 20 rounds of Bun.gc(true) and a timer:

1. Heap snapshot

files whose URLSearchParams are there GlobalObject cells
the collector's count (heapStats()) 6, 11, 12 3
reached from roots in the snapshot 12 1

1,973 cells of the snapshot reach the sixth file's GlobalObject (0x3ef6…). No root reaches any of them.

2. The stack, read from /proc/<pid>/mem while the test sleeps. Frames are from the rbp chain. One live word points into the 1,973 cells:

rbp 0x7ffebe13b3c0  return address in bun_runtime::jsc_hooks::auto_tick
rbp 0x7ffebe13b480  return address in TestCommand::run             (test_command.rs:3185, the loop that waits for the file's tests)
    0x7ffebe13b6d8  0x3ef6cf87ff0   a JSFunction of the sixth file, offset 16   = -0x2a8(%rbp) of TestCommand::run
rbp 0x7ffebe13b980  return address in run_with_api_lock::call::<run_all_tests::{closure#0}>

TestCommand::run uses -0x2a8(%rbp) only for the capacity of a Vec in Snapshots::keep_entries_of_tests (snapshot.rs:568 to 602, inlined at test_command.rs:3207), which runs after the file's tests. So while the tests run, the slot has what an earlier call at that depth left in it. 112 bytes below it is a stale return address in JSC::IsoSubspace::didBeginSweepingToFreeList, the slow path of an allocation.

The test keeps a bound on the collector's count (at most one older file), because a heap snapshot cannot see what native code keeps alive through the stack, and that is what this test was written for.

robobun added a commit that referenced this pull request Oct 15, 2026
The `format_label` arm is rewritten by #44833, which has its own
handling for a cyclic `%j` row. This change keeps the console arm and
the binding only.
@robobun

robobun commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator

#45325 handles the same cycle case for the global console's %j arm (PercentTag::J in src/jsc/ConsoleObject.rs), which this branch does not change. It adds JSValue::json_stringify_for_format: JSON.stringify that returns [Circular] for JSC's own cycle error without running user code, and passes every other failure on.

If one native rule for the cycle is wanted, write_json (jest.rs:816) and the Vitest %j arm (jest.rs:999) can call it and keep only the BigInt branch. #45325 does not touch jest.rs or jest-each.test.ts, so the two PRs do not conflict.

robobun added a commit that referenced this pull request Oct 16, 2026
spyOn() and mockRestore() wrote with putDirect(), putDirectIndex() and
putDirectAccessor() and read none of the results. So:

- spyOn() on a WebAssembly GC struct or array aborted the process. Its
  Structure never transitions.
- An index key on a frozen array or object returned a spy that was not
  installed.
- A name that a module does not export also returned a spy that was
  not installed.
- A restore that the object refused left the spy installed, forgot it,
  and stopped restoreAllMocks() before the other spies.

Install and restore now go through one writer, putOwnPropertyStorage().
A Proxy or a WebAssembly GC object that is not extensible decides
through its own defineOwnProperty(). Every other object keeps the
direct store, so a spy on a frozen object with a named key, and on
read-only builtins such as Bun.fetch, works as before. A refused store
throws a TypeError.

clearSpy() writes first and forgets the spy only when the write
succeeded. restoreAllMocks() restores every spy it can, keeps the ones
that were refused, and rethrows the first error.

The single writer with a defineOwnProperty() arm and the restore loop
follow the design in #44833.

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
This was referenced Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment